feat(terminal): kitty graphics state, replay, child input encoding, and an honest scrollback - #13
Merged
schickling merged 3 commits intoSep 5, 2026
Conversation
schickling-assistant
marked this pull request as ready for review
September 4, 2026 17:54
The session's terminal becomes the owner of two things a consumer cannot correctly own itself. Kitty graphics. libghostty holds the image storage; `pty-terminal::graphics` turns its borrowed handles into owned values (image bytes, placement identity, resolved source crop, rendered pixel and cell size, position in the window that was read) and puts the storage back on the wire for the ATTACH/PEEK replay. That last part is the point: a client that attaches after the child drew an image never saw the DATA that carried it, and libghostty's VT serialization keeps the placeholder cells but neither the images nor the placements. One bound covers state and wire, so nothing the terminal accepts is unreachable by a late client. A virtual placement is located by decoding its placeholder cells, which is what survives scrolling and a windowed read. Child input. Keys (kitty keyboard included), mouse, focus, and paste are encoded inside the terminal, from the terminal's own state, because what the child expects depends on modes the child itself set: DECCKM, the keypad mode, modifyOtherKeys, the kitty flags, the tracking mode and report format, bracketed paste. A second encoder outside would be a second implementation of the kitty keyboard protocol. Cell pixel metrics travel from the client on ATTACH and RESIZE as an optional suffix older readers ignore: they come from a font on the client's host, and a placement that named neither `c=` nor `r=` derives its cell extent from them. docs/decisions/0012-kitty-graphics-replay.md records the deviations, the bounds, and the test index. Refs #3 agent-identity: dev3.direct.omp.2gz9tcpa agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
libghostty's `Options::max_scrollback` is documented as a line count and is a byte budget for the history page list. Passing 10 000 for "10 000 lines" bought one page: 745 rows at 80 columns, 456 at 200, 3 310 at 20, and doubling the number changed nothing because both values are smaller than a page. A session that promised a 10 000-line replay window delivered 7% of it, and `scrollback_capacity()` reported a number that described nothing. The line count is now converted to a byte budget that scales with the width (256 + 16 bytes per column per line, against a measured ~838 at 80 columns and ~1 804 at 200), capped at 64 MiB. `scrollback()` reports what is retainable, `scrollback_request()` what was asked for, and `scrollback_bytes()` the budget libghostty holds it in — so the reported numbers describe the terminal rather than the request. Two consequences are recorded rather than hidden: capacity is a guaranteed minimum instead of Node's ceiling, because libghostty never holds less than one page; and widening a terminal lowers the line count it can retain, because libghostty takes the budget in `Options` and exposes no setter. docs/decisions/0013-scrollback-is-a-line-promise.md has the measurements, the memory cost, and the remaining gap. Refs #3 agent-identity: dev3.direct.omp.2gz9tcpa agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
agent-identity: dev3.direct.omp.2gz9tcpa agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-04-kitty-graphics-state
branch
from
September 5, 2026 07:09
c249b18 to
16fe0da
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The session's terminal has no image state and no child input encoder, and both
gaps push work onto consumers that consumers cannot do correctly.
Images are lost by the replay. A child can transmit a kitty image and place
it, and a client connected at that moment sees the bytes. A client that
attaches afterwards does not: the
SCREENpayload keeps the placeholder cells(they are ordinary text with a foreground colour) and nothing else. On
main,with a child that transmitted a 1x1 PNG as image 4242 and placed it virtually:
The placeholder cell survives and names an image nobody has. No API can even
report that, because there is no image state to report.
The 10,000-line scrollback promise was 7% true. libghostty's
Options::max_scrollbackis documented as "maximum number of lines to keep inscrollback history" and is in fact a byte budget for the history page list.
Passing
10_000for "10,000 lines" bought one page. Fed 10,008 short,non-wrapping lines on
main:L6698L9263L9552L9859Retention scales inversely with the width, which a line count cannot do, and
doubling the number to 20,000 changes nothing at 80 columns — both are smaller
than one page, and one page is the floor.
scrollback_capacity()meanwhilereported
24 + 10_000, describing nothing that existed.Input encoding has nowhere correct to live. What bytes a key becomes
depends on modes the child set — DECCKM, the keypad mode,
modifyOtherKeys,the kitty keyboard flags — and what a mouse event becomes depends on the
tracking mode and report format.
Modesdid not even track?1(DECCKM) or?9(X10), so a consumer could not encode an arrow key or tell press-only X10from wheel-reporting
?1000. The alternative is a second encoder outside theterminal, which means a second implementation of the kitty keyboard protocol.
Goal
identity, source crop, and position — after a live write, and after a replay
or reconnect, from a client that never saw the original
DATA.numbers it reports describe the terminal rather than the request.
Decisions
Graphics live in the terminal, and the replay carries them. Two other
shapes were considered and rejected. Passing the child's graphics bytes through
to an outer terminal cannot work for an embedder that draws a sub-rectangle:
the child's coordinates are its own, and the embedder clips, pans, and draws
chrome around it. Keeping the state per client cannot work either, because a
late client never sees the
DATA. Re-emitting the storage in the protocol thechild used is the smallest thing that makes a late client and a live client
hold the same images.
One bound for the state and the wire.
graphics::MAX_STORAGE_BYTES(32 MiB) is clamped to by
enable_graphics/set_graphics_storage_limit, anda replay carries all of it. A separate, smaller replay cap was tried first and
was wrong: it made a supported state — an image the terminal accepted and
reports through
image_bytes— one that a late client could never be given.Virtual placements are located by their cells. libghostty reports no
viewport position for a virtual placement, correctly: it has none, it is
wherever its placeholder cells are. Those cells each name their own image row
and column, so
graphicsdecodes them from the grid (one bounded pass over therequested window, never the scrollback). That is what makes a partially
scrolled image answerable, and it is also why a virtual placement can be
replayed regardless of where it currently shows.
Positions resolve against the window that was asked for. Direct placements
use the placement's own rectangle in screen space rather than
viewport_pos, which answers only for the live viewport. Otherwise ascrolled-back reader would see virtual images and lose cursor-positioned ones.
Input events are dumb; encoding is not.
input::KeyEventcarries thelogical key, the associated text, and the unshifted codepoint separately,
because the kitty protocol reports them separately — folding shift into one
character upstream loses the shifted alternate. The encoders themselves are
libghostty's, configured from the live terminal.
Key/Mods/actions arere-exported rather than re-declared: a second key enum would be a translation
table that silently drifts.
Cell metrics travel on the wire as an optional suffix. Four bytes appended
after the existing rows/cols on ATTACH and RESIZE. Every reader of a size
payload takes rows and cols from the first four bytes and the frame carries its
own length, so an older daemon — the Node one included — reads exactly the size
it always read. Undeclared is explicit (
GraphicsState::cell_declared), with adeterministic 8x16 fallback, rather than a silent guess.
The scrollback line count is converted, not passed through. The
alternative was to weaken the promise to "up to N lines" — which is what an
assertion of
used <= capacityamounts to, since it passes when 6,398 of10,008 lines have been thrown away. A replay window is a product promise: a
consumer decides what history to show on the basis of that number, so the
honest options were to meet it or to publish a smaller one. The conversion is
256 + 16 * colsbytes per line against a measured ~838 bytes per row at 80columns and ~1,804 at 200 — about 1.8x headroom, because the page list rounds
up to whole pages and a styled or multi-codepoint row costs more than a plain
one — capped at 64 MiB. libghostty exposes the budget only in
Options, withno setter, which is the source of the one residual gap (see Concerns).
Modesgains?1and?9as separate facts. Neither joinsmouse_tracking()(X10 reports no wheel, and a consumer must be able to tellthat from no tracking at all) and neither enters the Node-parity mode prefix.
Verification
nix flake check— the required gate, which builds the package and runs theworkspace suite:
Focused suites, 420 passing, 0 failing across
pty-terminalandpty-core(
graphics26,handle10,input13,replay21,scrollback6,protocol27, rest unchanged):Scrollback,
tests/scrollback.rs— 10,008 short non-wrapping lines into a24x80 terminal with the default 10,000-line scrollback, so nothing depends on
reflow:
The same at every width the per-row cost differs across
(
the_promise_holds_at_every_width):The behaviour change, end to end against a real session daemon
(
tests/handle.rs::a_late_attach_gets_the_image_the_child_drew_before_it_connected):a child transmits a PNG, places it virtually, writes a placeholder cell; a
handle attaches 400 ms later, so it never sees that
DATA.Rebuilding the daemon without its
enable_graphicscall makes exactly thattest fail with "the replay carried no placement", which is the negative control
for the daemon-side ownership.
Cell geometry, same rig
(
a_client_declares_its_cell_size_and_the_session_geometry_follows) — a 16x16image placed with no
c=/r=:Input encoding, from
tests/input.rs:Replay fidelity has a case per failure mode it can have, each of which fails on
the shape that preceded it: crop preserved through a replay, virtual placement
scrolled into history still replayed, partially scrolled direct placement
replayed clipped, grayscale PNG stored as RGBA, palette foreground recognised,
bare continuation cells inheriting row and id high byte, storage limit raising
that keeps the cell size and the PNG decoder, and the normal screen's images
landing on the client's normal screen after a full-screen program exits.
Complexity
Two new modules in an existing crate, one new dependency, one protocol suffix.
graphics.rsis large because the protocol is: image storage, placements,crop resolution, placeholder-cell decoding (the diacritic table is 297
entries of data), and replay emission. It has no abstraction over
libghostty — it is the boundary that makes libghostty's borrowed handles
owned and
Send.pngis a new dependency. libghostty rejects PNG transmissions without adecoder, and its own
RustPngDecoderis unusable (no constructor, and itsbuffer is reserved but never sized). Most senders use
f=100.new message type.
Concerns
allocation — a child that never transmits holds nothing — but a child that
wants to can make its session hold that much, and a replay then carries it.
That is the deliberate cost of a lossless replay; the alternative was
dropping images silently.
SCREENpayload for graphics sessions. Itis empty for a session that never sent an image, so non-graphics sessions are
byte-identical to before (pinned by test).
with different fonts cannot both be right about an implicit placement, and
unlike rows and cols there is nothing to reconcile — the metrics change no
bytes and no client's screen. Recorded in the decision record.
columns (33 MiB at 200, 63.5 MiB at 400), capped at 64 MiB. The budget is
address space the page list fills only as history accumulates, so an idle
session pays nothing — but a fleet of sessions that all fill their history
now costs what a 10,000-line window actually costs. That is the price of the
promise being true; the alternative was publishing a smaller window.
the byte budget in
Optionsand exposes no setter, so a terminal created at80 columns and widened to 400 holds about a fifth of the lines.
scrollback()andscrollback_capacity()follow it down andscrollback_request()still reports what was asked for, so the number isnever a lie — but the gap is real. Closing it needs either a libghostty
setter (upstream) or budgeting for the widest plausible width up front
(10,000 lines at 1,000 columns is 154 MiB per session, which is not worth
it). Called out as a follow-up.
scrollback_capacity()is now a floor, not a ceiling. libghostty neverholds less than one page, so a small scrollback retains more than it
promised (a 100-line request at 80 columns keeps ~1,000 rows). Node's number
is a ceiling. Code that treated it as an upper bound on
scrollback_usedhas to stop.
pty-terminalis doing more than it did. Graphics state and inputencoding are both genuinely terminal state, but the crate is now the place
where a consumer's whole terminal contract lives.
crates/pty/src/daemon/;this PR's daemon changes are
lifecycle.rs::terminal_actorandclients.rs::adopt_cell_size. A textual conflict is possible, semanticallyindependent.
Friction & bottlenecks
libghostty_vt::kitty::graphics::RustPngDecodercannot be constructed(private field, no
new, noDefault) and would not work if it could — itreserves capacity for the decode buffer but never sets its length, so
next_framegets an empty slice. Worked around with a local decoder.png::Transformations::ALPHAis documented as expanding paletted images,and there is no grayscale-to-RGB transformation at all, so
EXPAND | ALPHA | STRIP_16does not produce RGBA for a grayscale PNG the wayits name suggests. The expansion has to be done by hand.
viewport_posand its placementrectanswer in differentcoordinate spaces, and only the latter can answer for a window other than the
live viewport. Not documented as a choice; found by testing.
libghostty_vt::terminal::Options::max_scrollbackis documented as a linecount and is a byte budget. The discrepancy is invisible until you count
retained rows at two different widths, and it silently degrades any consumer
that trusts the doc comment. Worth an upstream report.
crates/pty/tests/daemon_geometry.rshas 3 failures on this machine thatreproduce identically with this PR's daemon change reverted, so they are
pre-existing and unrelated.
nix flake checkis green, so they appear to beenvironment-specific.
Follow-ups
line promise; without it, a widened terminal retains fewer lines (see
Concerns). Worth raising upstream together with the
max_scrollbackdocdiscrepancy.
daemon's tolerance of it is argued from its decode offsets and the explicit
frame length, not proven against the binary.
contract an embedder can reproject into a sub-rectangle, so a child using
them still shows nothing.
GraphicsState:libghostty exposes lookup by id, not enumeration. Such an image is also not
drawable.
daemon_geometryfailures noted above deserve their owninvestigation.
References
Refs #3 — one embedded terminal API for attached and
spawned PTYs (this adds the graphics and input halves of that interface).
Refs schickling/dotfiles#1378 — the Fractal terminal-core convergence that
consumes it.
docs/vrs/01-images/requirements.md — the durable terminal-image contract.
docs/vrs/01-images/spec.md — the Kitty graphics mechanism and validation index.
docs/decisions/0012-kitty-graphics-replay.md — the graphics deviations, the
bounds, and the test index.
docs/decisions/0013-scrollback-is-a-line-promise.md — the scrollback
measurements, the conversion, the memory cost, and the residual gap.
Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile